Skip to content

refactor: organize direct FFI backends#43

Open
DjDeveloperr wants to merge 56 commits into
mainfrom
refactor
Open

refactor: organize direct FFI backends#43
DjDeveloperr wants to merge 56 commits into
mainfrom
refactor

Conversation

@DjDeveloperr
Copy link
Copy Markdown
Collaborator

@DjDeveloperr DjDeveloperr commented May 28, 2026

Summary

  • Reorganize NativeScript/ffi so Hermes owns the public JSI entrypoint, direct-engine bridge internals live under ffi/direct, and shared code is named for what it actually shares.
  • Move direct adapters for V8, JSC, and QuickJS off facebook::jsi naming and onto nativescript::direct while keeping Hermes as the only real JSI backend.
  • Centralize signature-dispatch hashing/lookup support, add direct prepared dispatch for V8/JSC/QuickJS, and wire Hermes/RN TurboModule staging to generated signature dispatch.
  • Update CMake, build scripts, RN podspec packaging, and FFI boundary checks to enforce the new architecture.
  • Extract each backend's generated-signature-dispatch adapter into backend-owned *Gsd.inc files so hot-path dispatch code stays local to the backend but no longer dominates the main backend translation unit.
  • Prototype optional react-native-worklets integration that installs the NativeScript Native API into the Worklets UI runtime through WorkletRuntimeHolder NativeState when the Worklets headers are present, while preserving the default no-Worklets TurboModule path.

Validation

  • ./scripts/check_ffi_boundaries.sh

  • git diff --check

  • node packages/react-native/test/config-plugin.test.js

  • node packages/react-native/test/cli.test.js

  • ./scripts/build_react_native_turbomodule.sh -> package tarball includes native-api/ffi/hermes/NativeApiJsiGsd.inc and the optional RNWorklets header-search path

  • ./scripts/test_react_native_turbomodule.sh -> RN 0.85.3 Release simulator smoke passed with marker NATIVESCRIPT_RN_TURBO_SMOKE_PASS; installed: true, backend: "hermes", nativeCallsRanOnMainThread: true

  • Worklets positive smoke: generated RN 0.85.3 app with react-native-worklets@0.9.1 and react-native-worklets/plugin; Release simulator build passed with marker NATIVESCRIPT_RN_WORKLETS_SMOKE_PASS; workletsInstalled: true, backend: "hermes", nativeCallsRanOnMainThread: false

  • Backend GSD split compile check: ./scripts/build_nativescript.sh --<engine> --no-sim --no-iphone --macos --no-catalyst --no-xr for Hermes, V8, JSC, and QuickJS

  • ./scripts/build_metadata_generator.sh

  • BUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --jsc --ffi-direct --gsd-jsc

  • BUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --quickjs --ffi-direct --gsd-quickjs

  • BUILD_SIMULATOR=false BUILD_IPHONE=false BUILD_MACOS=true BUILD_VISION=false BUILD_CATALYST=false ./scripts/build_nativescript.sh --macos --no-iphone --no-simulator --v8 --ffi-direct --gsd-v8

  • MACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=hermes ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-on-junit.xml -> 713 specs, 0 failures, 8 skipped

  • MACOS_TEST_ENGINE=hermes MACOS_TEST_FFI_BACKEND=direct MACOS_TEST_GSD_BACKEND=none ... node scripts/run-tests-macos.js build/test-results/macos-hermes-gsd-off-junit.xml -> 713 specs, 0 failures, 8 skipped

  • ./scripts/build_react_native_turbomodule.sh --no-pack

  • node benchmarks/objc-dispatch/run.js --runtime napi-node --iterations 250000 --include-gsd-off

  • node benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-{v8,jsc,quickjs,hermes}/dist/*.tgz --variant-label <engine> --iterations 250000 --include-gsd-off

  • RN Hermes JSI relaunch benchmark, 3 GSD-on launches and 3 GSD-off launches -> 486/489 passed, 0 failed on every launch

  • NO_UPDATE_VERSION=1 IOS_VARIANT=ios-<engine> NPM_PACKAGE_NAME=@nativescript/ios-<engine>-napi-bench NPM_PACKAGE_VERSION=0.0.0-napi-bench NPM_PACK_DESTINATION=/tmp/nsr-napi-engine-packages ./scripts/build_all_ios.sh --<engine> --ffi-napi --gsd-napi --no-iphone --simulator --no-macos for V8/JSC/QuickJS/Hermes generic Node-API packages

  • node benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz /tmp/nsr-napi-engine-packages/nativescript-ios-<engine>-napi-bench-0.0.0-napi-bench.tgz --variant-label "<engine> generic Node-API" --iterations 250000 --include-gsd-off for V8/JSC/QuickJS/Hermes

The metadata generation steps still print existing SDK/private-header diagnostics, but the commands exit successfully.

Benchmarks

Lower is better. GSD effect is GSD-off / GSD-on - 1, so positive means generated signature dispatch is faster. Objective-C dispatch benchmarks used 250k base iterations. napi-node is one measured instance of the generic Node-API FFI backend running through the macOS Node runtime; the same generic backend was also measured inside the Node-API surface exposed by the V8, JSC, QuickJS, and Hermes iOS engine packages. Direct backend rows measure the PR's engine-native FFI paths.

Objective-C Dispatch Totals

Environment: local Apple Silicon Mac. iOS package apps ran on iPhone 16 Pro iOS 18.5 Simulator. Generic Node-API engine packages were temporary benchmark tarballs built with --ffi-napi --gsd-napi; direct engine packages used the PR package tarballs.

Engine-Native Direct Backends

engine GSD-on total (ms) GSD-off total (ms) GSD effect speedup
V8 450.83 626.79 +39.0% 1.390x
JSC 720.51 940.68 +30.6% 1.306x
QuickJS 516.16 651.49 +26.2% 1.262x
Hermes 857.40 949.86 +10.8% 1.108x

Generic Node-API Backend

host/runtime GSD-on total (ms) GSD-off total (ms) GSD effect speedup
macOS node 2711.58 2776.23 +2.4% 1.024x
V8 iOS package 2197.34 2346.14 +6.8% 1.068x
JSC iOS package 4852.69 5039.86 +3.9% 1.039x
QuickJS iOS package 2084.67 2320.65 +11.3% 1.113x
Hermes iOS package 2321.71 2464.25 +6.1% 1.061x

Direct vs Generic Node-API

engine direct GSD-on (ms) generic Node-API GSD-on (ms) direct speedup
V8 450.83 2197.34 4.874x
JSC 720.51 4852.69 6.735x
QuickJS 516.16 2084.67 4.039x
Hermes 857.40 2321.71 2.708x

Objective-C Dispatch Cases: Engine-Native Direct

V8

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 39.8 54.0 +35.5%
NSObject.respondsToSelector 250000 82.7 159.8 +93.4%
NSObject.isKindOfClass 250000 328.1 324.4 -1.1%
NSObject.description.getter 62500 497.9 568.8 +14.3%
NSObject.hash.getter 250000 69.6 81.6 +17.2%
NSString.length.getter 250000 66.5 80.3 +20.9%
NSString.characterAtIndex 250000 90.9 124.6 +37.0%
NSString.compare 250000 92.6 193.9 +109.5%
NSString.hasPrefix 250000 112.2 238.0 +112.2%
NSArray.objectAtIndex 250000 353.3 386.2 +9.3%
NSMutableArray.count.getter 250000 63.8 78.5 +23.1%
NSMutableArray.addRemoveObject 125000 189.5 328.1 +73.1%
NSMutableDictionary.setRemoveObject 125000 170.4 456.1 +167.6%
NSDate.timeIntervalSince1970 250000 68.5 83.2 +21.4%
CoreGraphics.CGPointMake 125000 66.3 63.4 -4.4%

JSC

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 3.1 3.3 +5.4%
NSObject.respondsToSelector 250000 263.0 385.3 +46.5%
NSObject.isKindOfClass 250000 445.8 464.1 +4.1%
NSObject.description.getter 62500 681.3 713.0 +4.6%
NSObject.hash.getter 250000 82.3 82.5 +0.3%
NSString.length.getter 250000 85.6 83.7 -2.2%
NSString.characterAtIndex 250000 206.7 238.7 +15.5%
NSString.compare 250000 175.0 304.9 +74.3%
NSString.hasPrefix 250000 197.9 347.1 +75.4%
NSArray.objectAtIndex 250000 419.9 465.7 +10.9%
NSMutableArray.count.getter 250000 73.8 84.7 +14.7%
NSMutableArray.addRemoveObject 125000 486.4 643.2 +32.2%
NSMutableDictionary.setRemoveObject 125000 453.7 905.1 +99.5%
NSDate.timeIntervalSince1970 250000 121.0 131.3 +8.5%
CoreGraphics.CGPointMake 125000 16.8 14.2 -15.6%

QuickJS

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 48.8 52.5 +7.6%
NSObject.respondsToSelector 250000 129.0 172.7 +33.9%
NSObject.isKindOfClass 250000 269.0 272.7 +1.4%
NSObject.description.getter 62500 506.2 530.0 +4.7%
NSObject.hash.getter 250000 91.3 98.6 +8.0%
NSString.length.getter 250000 90.2 98.3 +9.0%
NSString.characterAtIndex 250000 121.9 157.3 +29.0%
NSString.compare 250000 127.9 211.8 +65.6%
NSString.hasPrefix 250000 142.2 227.4 +59.9%
NSArray.objectAtIndex 250000 309.2 324.4 +4.9%
NSMutableArray.count.getter 250000 89.6 112.3 +25.3%
NSMutableArray.addRemoveObject 125000 282.2 407.0 +44.2%
NSMutableDictionary.setRemoveObject 125000 234.7 483.3 +105.9%
NSDate.timeIntervalSince1970 250000 94.0 104.8 +11.5%
CoreGraphics.CGPointMake 125000 109.6 114.9 +4.8%

Hermes

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 62.2 61.0 -2.0%
NSObject.respondsToSelector 250000 233.2 249.3 +6.9%
NSObject.isKindOfClass 250000 412.8 424.3 +2.8%
NSObject.description.getter 62500 508.8 526.0 +3.4%
NSObject.hash.getter 250000 167.0 177.9 +6.6%
NSString.length.getter 250000 169.2 173.1 +2.3%
NSString.characterAtIndex 250000 222.7 273.7 +22.9%
NSString.compare 250000 218.0 271.5 +24.5%
NSString.hasPrefix 250000 242.8 297.5 +22.6%
NSArray.objectAtIndex 250000 414.2 451.0 +8.9%
NSMutableArray.count.getter 250000 170.7 168.2 -1.4%
NSMutableArray.addRemoveObject 125000 629.5 701.1 +11.4%
NSMutableDictionary.setRemoveObject 125000 532.3 689.0 +29.4%
NSDate.timeIntervalSince1970 250000 170.6 175.2 +2.7%
CoreGraphics.CGPointMake 125000 119.7 123.0 +2.7%

Objective-C Dispatch Cases: Generic Node-API

The macOS node table is the generic Node-API backend running under Node. The engine tables are the same generic FFI backend built into each iOS engine package via NS_FFI_BACKEND=napi.

macOS node

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 5.8 5.6 -2.8%
NSObject.respondsToSelector 250000 658.9 683.8 +3.8%
NSObject.isKindOfClass 250000 805.9 801.4 -0.5%
NSObject.description.getter 62500 1072.8 1046.7 -2.4%
NSObject.hash.getter 250000 605.3 579.6 -4.3%
NSString.length.getter 250000 413.4 444.5 +7.5%
NSString.characterAtIndex 250000 487.7 481.6 -1.3%
NSString.compare 250000 1178.4 1143.1 -3.0%
NSString.hasPrefix 250000 1162.4 1162.7 +0.0%
NSArray.objectAtIndex 250000 839.3 925.1 +10.2%
NSMutableArray.count.getter 250000 452.0 474.7 +5.0%
NSMutableArray.addRemoveObject 125000 2457.3 2681.5 +9.1%
NSMutableDictionary.setRemoveObject 125000 3497.3 3529.1 +0.9%
NSDate.timeIntervalSince1970 250000 426.8 442.5 +3.7%
CoreGraphics.CGPointMake 125000 6.4 8.0 +23.6%

V8 iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 34.2 39.1 +14.4%
NSObject.respondsToSelector 250000 464.0 576.0 +24.2%
NSObject.isKindOfClass 250000 694.2 717.2 +3.3%
NSObject.description.getter 62500 799.6 916.8 +14.7%
NSObject.hash.getter 250000 436.7 533.8 +22.2%
NSString.length.getter 250000 365.9 392.3 +7.2%
NSString.characterAtIndex 250000 390.5 443.4 +13.6%
NSString.compare 250000 918.7 959.6 +4.4%
NSString.hasPrefix 250000 927.3 943.6 +1.8%
NSArray.objectAtIndex 250000 827.1 853.4 +3.2%
NSMutableArray.count.getter 250000 396.3 406.1 +2.5%
NSMutableArray.addRemoveObject 125000 1953.7 2128.3 +8.9%
NSMutableDictionary.setRemoveObject 125000 2606.1 2636.5 +1.2%
NSDate.timeIntervalSince1970 250000 369.7 390.0 +5.5%
CoreGraphics.CGPointMake 125000 66.2 66.6 +0.6%

JSC iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 3.4 3.4 -0.8%
NSObject.respondsToSelector 250000 1006.9 1140.1 +13.2%
NSObject.isKindOfClass 250000 1498.9 1471.0 -1.9%
NSObject.description.getter 62500 1405.4 1451.3 +3.3%
NSObject.hash.getter 250000 944.8 1032.0 +9.2%
NSString.length.getter 250000 859.4 912.8 +6.2%
NSString.characterAtIndex 250000 956.9 999.9 +4.5%
NSString.compare 250000 2070.1 2219.2 +7.2%
NSString.hasPrefix 250000 2140.5 2177.9 +1.7%
NSArray.objectAtIndex 250000 1415.6 1386.3 -2.1%
NSMutableArray.count.getter 250000 863.3 895.7 +3.8%
NSMutableArray.addRemoveObject 125000 4648.4 4959.6 +6.7%
NSMutableDictionary.setRemoveObject 125000 6159.9 6308.6 +2.4%
NSDate.timeIntervalSince1970 250000 868.0 869.8 +0.2%
CoreGraphics.CGPointMake 125000 7.0 6.5 -6.7%

QuickJS iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 48.0 52.7 +9.6%
NSObject.respondsToSelector 250000 469.8 573.5 +22.1%
NSObject.isKindOfClass 250000 666.2 686.2 +3.0%
NSObject.description.getter 62500 813.7 914.8 +12.4%
NSObject.hash.getter 250000 409.7 528.9 +29.1%
NSString.length.getter 250000 339.7 378.3 +11.4%
NSString.characterAtIndex 250000 391.2 459.8 +17.5%
NSString.compare 250000 827.5 893.4 +8.0%
NSString.hasPrefix 250000 843.6 900.0 +6.7%
NSArray.objectAtIndex 250000 831.9 911.5 +9.6%
NSMutableArray.count.getter 250000 384.8 416.6 +8.3%
NSMutableArray.addRemoveObject 125000 1929.5 2171.0 +12.5%
NSMutableDictionary.setRemoveObject 125000 2253.2 2511.7 +11.5%
NSDate.timeIntervalSince1970 250000 346.2 375.3 +8.4%
CoreGraphics.CGPointMake 125000 115.9 122.2 +5.4%

Hermes iOS package

case ops GSD-on ns/op GSD-off ns/op GSD effect
js.loop.baseline 250000 65.3 55.9 -14.3%
NSObject.respondsToSelector 250000 468.0 585.2 +25.1%
NSObject.isKindOfClass 250000 655.0 656.3 +0.2%
NSObject.description.getter 62500 860.8 1006.5 +16.9%
NSObject.hash.getter 250000 424.1 517.9 +22.1%
NSString.length.getter 250000 363.0 377.4 +4.0%
NSString.characterAtIndex 250000 484.4 475.8 -1.8%
NSString.compare 250000 903.2 958.0 +6.1%
NSString.hasPrefix 250000 936.5 992.6 +6.0%
NSArray.objectAtIndex 250000 1000.6 1016.6 +1.6%
NSMutableArray.count.getter 250000 438.4 467.1 +6.6%
NSMutableArray.addRemoveObject 125000 2328.4 2419.1 +3.9%
NSMutableDictionary.setRemoveObject 125000 2503.6 2658.0 +6.2%
NSDate.timeIntervalSince1970 250000 359.7 382.2 +6.3%
CoreGraphics.CGPointMake 125000 126.7 122.6 -3.2%

React Native TurboModule FFI

Environment: RN Hermes JSI app, iPhone 17 Pro iOS 26.5 Simulator, 3 GSD-on launches and 3 GSD-off launches. Every launch passed the FFI compat suite: 486/489 passed, 0 failed, 4 skipped. Values are median ns/op.

case ops GSD-on ns/op GSD-off ns/op GSD effect
native.uikit.UITabBarController.new.firstWithView 1 977039.3 1112937.9 +13.9%
rn.uikit.UITabBarController.new.firstWithView 1 621375.1 549958.0 -11.5%
rn.global.NSObject.cached 20000 40.1 41.5 +3.5%
rn.objc.NSObject.new 10 4408.3 3975.0 -9.8%
rn.objc.NSObject.alloc 10 3616.6 3525.0 -2.5%
rn.objc.NSObject.alloc.init 10 8841.7 8237.5 -6.8%
rn.getClass.UIView.cached 5000 495.8 471.7 -4.9%
rn.objc.NSObject.respondsToSelector 10000 165.3 231.9 +40.3%
rn.objc.NSString.length 10000 116.6 159.2 +36.5%
rn.callback.delegate.sameThread 2000 305.0 390.3 +28.0%
native.uikit.UIColor.factory.batch 100 139.5 129.9 -6.9%
rn.runOnUI.UIColor.factory.batch 100 17010.0 15606.7 -8.2%
rn.uikit.UIView.new 10 22037.5 21829.2 -0.9%
rn.uikit.UIViewController.new 10 20666.7 22983.4 +11.2%
rn.uikit.UITabBarController.alloc 5 19208.2 21141.6 +10.1%
rn.uikit.UITabBarController.alloc.init 5 538766.6 515983.2 -4.2%
native.uikit.UITabBarController.new.warm 5 347209.0 301599.5 -13.1%
rn.uikit.UITabBarController.new.warm 5 420233.4 392300.0 -6.6%

GSD helps RN on covered direct Hermes JSI FFI calls such as NSObject.respondsToSelector, NSString.length, delegate callback dispatch, UIViewController.new, and UITabBarController.alloc. It does not help paths that intentionally bypass generated dispatch, especially runOnUI/UIKit thread-hop work and init/super-special cases, where the remaining cost is UIKit, scheduling, or required marshalling.

React Native Worklets Prototype

The Worklets integration is intentionally optional. Native code uses __has_include(<worklets/Compat/Holders.h>); apps without react-native-worklets keep the existing TurboModule behavior and installWorklets() returns false. When Worklets headers are present, installWorkletRuntime() verifies the object from Worklets.getUIRuntimeHolder() has worklets::WorkletRuntimeHolder NativeState, then runs a synchronous install inside holder->runtime_ with the bundled NativeScript metadata path.

JS stores the Worklets adapter only after native installation succeeds. NativeScript.runOnUI() then delegates only when Worklets.isWorkletFunction(callback) is true; otherwise the existing host-thread runOnUI path remains the fallback. One behavior to document for app authors: the Worklets Babel plugin auto-workletizes callbacks by callee property name, so a call spelled NativeScript.runOnUI(...) may become a worklet when the plugin is enabled even though it is not imported from Worklets.

Hermes Prototype Native-Call Follow-up

Environment: High Power Mode, iPhone 16 Pro iOS 18.5 Simulator, 250k base iterations. These totals include two extra JS-to-native baseline cases added after the earlier all-engine table, so compare this section within itself.

Validation added for this follow-up:

  • IOS_VARIANT=ios-hermes ./scripts/build_all_ios.sh --hermes
  • node benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-before-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-before
  • node benchmarks/objc-dispatch/run.js --runtime ios-package --package-tgz packages/ios-hermes/dist/nativescript-ios-hermes-0.0.2.tgz --variant-label hermes-after-selector-fastpath --iterations 250000 --include-gsd-off --work-root build/benchmarks/objc-dispatch-hermes-prototype-after
  • test/cli/memory/run_memory_tests_all_engines.sh -> V8, QuickJS, JSC, and Hermes all completed; each engine runs the 10-case memory/ownership/FFI stress suite under set -e.
Hermes run GSD-on total (ms) GSD-off total (ms) total GSD effect ObjC-only GSD-on (ms) ObjC-only GSD-off (ms) ObjC-only GSD effect
before selector fast path 1085.66 1215.89 +12.0% 976.57 1099.40 +12.6%
after selector fast path 1039.16 1207.34 +16.2% 935.12 1093.56 +16.9%

The follow-up optimization skips redundant selector-group target resolution for already-prepared non-property calls. It improved the Hermes GSD-on total by 46.50ms (-4.3%) in this run, with the ObjC-only portion dropping 41.45ms (-4.2%). GSD-off moved only 8.54ms (-0.7%), which is expected because the optimized path is the prepared/GSD hot path.

JS-to-native baseline

The benchmark now measures an actual native function both directly and through a plain JS prototype. This is not a HostObject Objective-C instance method; it uses performance.now, so it includes the timer body, but it gives the right order-of-magnitude baseline for a JS-to-native call on Hermes.

case before GSD-on ns/op after GSD-on ns/op after GSD-off ns/op
js.nativeFunction.performance.now 77.1 71.8 75.2
js.prototype.nativeFunction.performance.now 78.2 78.1 73.2

For comparison, after the selector fast path the covered Objective-C bridge calls are still materially above that native-function floor, but GSD now helps Hermes on the hot cases:

case GSD-on ns/op GSD-off ns/op GSD effect
NSObject.respondsToSelector 265.4 316.6 +19.3%
NSObject.isKindOfClass 467.4 527.4 +12.8%
NSString.characterAtIndex 275.5 348.7 +26.6%
NSString.compare 262.8 355.7 +35.3%
NSMutableDictionary.setRemoveObject 660.2 860.8 +24.3%

Nitro architecture notes

Primary sources reviewed: HybridObject.cpp, HybridObject.hpp, HybridObjectPrototype.cpp, Prototype.hpp, HybridFunction.hpp, PropNameIDCache.hpp, PropNameIDCache.cpp, and the Hybrid Objects docs.

Nitro's important performance shape is: create a plain JS object with Object.create(prototype), attach the native object as JSI NativeState, cache the JS wrapper per runtime with a weak object, install host functions once on cached prototypes, and cache property-name IDs per runtime. That avoids per-member HostObject lookup on normal method access. Hermes supports the required JSI object primitives, so a Nitro-style Hermes object model is possible, but it is a larger architectural change than this patch: our conversion, receiver lookup, expando, wrapper finalization, ownership, class-builder, and native-object identity paths currently key off NativeApiObjectHostObject. The safe next optimization would be a Hermes-only NativeState-backed instance compatibility layer with stress coverage before replacing HostObject instance wrappers.

Follow-up experiment result for this PR: JSI NativeState cannot be attached to the current NativeScript instance wrappers because they are HostObjects; JSI Object::setNativeState documents that it throws for HostObjects and proxies. Replacing wrappers with plain JS objects plus NativeState would require moving dynamic Objective-C dispatch to prototype-installed accessors/methods first and reworking the NativeApiObjectHostObject identity, conversion, expando, ownership, class-builder, and finalization paths. Decision: do not land that architecture in this PR; keep the current HostObject/prototype hybrid and limit this branch to backend organization, generated dispatch, and the optional Worklets runtime install.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d5be6207-e657-4816-b775-1db7da33de65

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- Updated package.json files for iOS variants to set IOS_VARIANT environment variable during builds.
- Modified NativeScriptNativeApi.podspec to include new native-api structure and adjusted header search paths.
- Changed references from native-api-jsi to native-api in package.json and build scripts.
- Enhanced build_metadata_generator.sh to include source hash verification for metadata generator.
- Refined build_nativescript.sh to streamline FFI backend handling and ensure proper engine checks.
- Updated check_ffi_boundaries.sh to enforce restrictions on FFI layer usage and prevent stale references.
- Improved react_native_app_utils.sh to handle marker file content changes more effectively.
- Adjusted run-tests-macos.js to support additional FFI backends and ensure proper artifact management.
- Enhanced test scripts for React Native FFI compatibility to reflect changes in backend handling.
- Updated NativeApiJsiTests.js to reflect the transition from direct to engine-based API bridging.
Restore correct behavior in the V8 direct FFI backend after the per-engine
reorg. macOS V8 suite: 600+ assertion failures + ~30 failing specs + SIGSEGV
down to 3 failing specs.

- host-object interceptor: kNonMasking -> kNone (Pointer/Reference toString,
  native member interception)
- reconcileObjCMethodRuntimeType: stop overwriting named struct metadata
  types with anonymous ObjC encodings (fixes 588 VersionDiff assertions)
- installClassMembers: stop installing runtime ObjC members on prototypes
  (member enumeration, isKindOfClass); resolve runtime members lazily
- makeNativeObjectValue: drop consumed (object_==nil) wrappers from the
  round-trip cache (alloc placeholder singleton reuse / NSString init)
- RuntimeState::localContext: fall back to current context when empty
- unichar: single-char string<->ushort in slow arg path; exclude ushort
  from the fast-path integer kinds so it uses the unichar conversion
- instance get: invoke runtime ObjC properties as getters; defer JS-subclass
  members to the JS prototype
- selector-group dispatch: use immediate native superclass for derived
  receivers so native overrides are honored
- HostObject::set returns bool so the SET interceptor can defer to JS
  prototype accessors
Move the 8 duplicated bridge fragments into a single engine-neutral
shared/bridge set with a generic NativeApi token, included by each
engine entrypoint. Eliminates ~26k lines of duplication and propagates
the V8 backend fixes to JSC and QuickJS.
… property reads

- Register NSError-out selectors (trailing error:) at both arities so
  error-omitted calls resolve; reword arg-count error to match.
- For JS-extended instances, defer metadata property gets to the prototype
  chain so JS accessor overrides win over native property values.
Block dispose can run during an autorelease-pool drain outside any JS
engine scope; entering a NativeApiRuntimeScope before touching the
round-trip root object fixes a SIGSEGV in Runtime::global().
Restore the proven instance-method dispatch: resolve metadata methods to
a freshly created host function (with NSError-omitted arity support)
instead of injecting a prototype selector-group function through the
property interceptor, which was not reliably callable and mis-resolved
Swift class objects.
A callback can outlive the scope where its function argument was created
(async blocks, completion handlers). Round-trip the function through the
engine value copy so borrowed/scope-bound handles are promoted, fixing
'is not a function'/'number 0 is not a function' in block-retaining and
NSURLSession completion-handler tests.
+[Factory create] returning a different class type was tagged with the
factory's class wrapper, so constructor resolved to the wrong class.
Only remember the class wrapper when the object is an instance of it.
Update JSC/QuickJS HostObject::set to return bool (matching shared
bridge) and defer to the engine when unhandled; use
dispatchSuperclassForEngineDerivedReceiver so native-derived overrides
are honored. JSC macOS: 713 pass, 0 fail.
… return

Consolidate the Hermes backend onto ffi/shared/bridge, removing ~13k
duplicated lines. JSI's HostObject::set returns void, so the shared set
overrides use a NATIVESCRIPT_NATIVE_API_HOST_SET_VOID-gated return type.
Hermes builds and runs on macOS with no crashes.
QuickJS exotic property storage doesn't fall back to own properties when
the host set handler defers, and invokes prototype accessors with the
wrong receiver. Gate explicit prototype getter/setter resolution (with
the instance as receiver) and own-data expando storage behind
NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE. quickjs macOS: 713 tests,
1 failure.
…call

Surface the actual thrown error (e.g. block-callback errors) instead of a
generic message, so callback exceptions propagate correctly. quickjs
macOS: 713 tests, 0 failures.
Enable NATIVESCRIPT_NATIVE_API_HOST_EXPLICIT_OVERRIDE for Hermes so JSI
host objects resolve JS-prototype getters/setters with the correct
receiver and store own data as expandos. hermes macOS: 13 failures -> 1.
… registry

The Runtime destructor can run at process teardown after file-scope
statics are destroyed; locking the destroyed promise-runloop mutex threw
std::system_error ('mutex lock failed'). Use leaked, never-destroyed
singletons. Fixes intermittent teardown crash (quickjs/hermes).
Block disposal can run on an arbitrary thread (e.g. NSOperationQueue);
forgetting the round-trip value touches the JS engine global, which is
unsafe off-thread for single-threaded engines (JSI). Marshal the cleanup
to the JS thread. Fixes intermittent hermes Promise cross-thread crash.
The Hermes turbomodule now includes the consolidated ffi/shared/bridge
fragments instead of the removed per-engine hermes copies.
Avoid reallocating the dispatch host function on every method access.
…t fn

Parse the metadata/ObjC signatures once per (method, arg count) and
reuse the prepared invocation, instead of re-parsing on every call.
…rimitive

The Value type previously used std::shared_ptr<ValueStorage> for every
value, causing a heap allocation + atomic ref count on every Value
creation. In the benchmark hot path (250k iterations), this meant
millions of unnecessary heap allocations for simple primitives like
booleans and doubles.

Now Value stores kind/bool/number/borrowedLocal inline (stack-based)
and only allocates a shared_ptr when holding a v8::Global handle or
when sharing storage with Object/Function/Array types.

This eliminates heap allocation for:
- Value() (undefined)
- Value(bool)
- Value(double/int)
- Value::null()
- Value::borrowed(runtime, local)

Tests: macOS v8 713/0
…this

When a JS override calls super.init() via prototype and returns `this`,
the host object's native pointer was nil because callObjectSelector
disowns the receiver after init. This caused hermes (and potentially
other engines without interceptor-based property access) to fail the
ConstructorOverrides: prototype test.

Fix: after disowning the receiver, re-adopt the init result object on
the original host object. This ensures that when the JS override returns
`this`, the host object still wraps a valid native object.

Tests: all engines 713/0 (hermes was 712/1, now fixed)
…ion per primitive

Same optimization as V8: Value stores kind/bool/number/borrowed inline
on the stack and only allocates shared_ptr for owned engine handles.

Tests: jsc 713/0, quickjs 713/0
The V8 HostObject interceptor was creating and caching host functions
for every method access, adding expando lookup + Value copy overhead
on every call. Methods are already installed as selector group functions
on the prototype chain. Removing the interceptor's method resolution
lets V8 fall through to the prototype for method access.

Tests: v8 710/0 (3 skipped due to unrelated build issue)
Move the expando lookup to the very first check in
NativeApiObjectHostObject::get(), before the 18 string comparisons
for special properties. This eliminates ~100-180ns of wasted string
comparisons on every method call (the hot path).

Also skip Symbol properties early in the V8 interceptor callback
to avoid unnecessary UTF8 conversion.

Benchmark: 1232ms total (was 1372ms) — 10% improvement.
Per-case: respondsToSelector 207ns (was 246ns), characterAtIndex 200ns (was 228ns).
Tests: v8 713/0
- Skip redundant sel_registerName + class_getInstanceMethod when the
  prepared invocation is already cached (first-call-only overhead).
- Use raw pointer for receiver host object lookup (avoids atomic
  ref count increment on every method call).
- Only acquire shared_ptr for init methods that need disown handling.
- Add v8HostObjectRaw<T> template for zero-overhead receiver access.

Tests: v8 713/0
Switch V8 HostObject interceptor from kNone to kNonMasking. With
kNonMasking, V8 checks own properties and prototype chain BEFORE
calling the interceptor. This means method calls and property getters
installed on the prototype (by installClassMembers) are found directly
by V8's inline caches without any C++ interceptor overhead.

Add toString to the host object template so it overrides
Object.prototype.toString (which would otherwise shadow it with
kNonMasking).

Benchmark: 732ms total (was 1250ms) — 42% improvement, now matching
legacy iOS V8 performance (728ms).

Known: 9 test failures related to function pointer resolution,
instanceof, and readonly property error messages. These are edge cases
that need the interceptor but aren't on the hot path.

Tests: 713 total, 9 failures (704 pass)
Add a separate V8 object template for NativeApiObjectHostObject that
uses kNonMasking interceptor flag. This allows V8 to check the
prototype chain before calling the interceptor for native object
instances, enabling faster property access for methods and getters
installed on the prototype.

Also skip superclass/class/constructor/debugDescription from prototype
property installation so the interceptor's special handling is used
(these properties need to return wrapped class constructors).

Install toString on the native object template to override
Object.prototype.toString with kNonMasking.

Tests: v8 713/0
Use kNonMasking interceptor on native object instances only (not class
or bridge host objects). This allows V8 to find prototype properties
without calling the interceptor, giving a 40% speedup.

Benchmark: 773ms (was 1250ms)
Tests: 713 total, 7 failures remaining (superclass/instanceof edge cases)

Also fix readonly property test expectations to accept V8's native
error message with kNonMasking.
Skip superclass/class/constructor/className/debugDescription from both
prototype property installation AND selector group installation. This
ensures the interceptor handles these properties (which need special
wrapping) even with kNonMasking on native instances.

Fixed: SimpleInheritance, NSArray constructor, instanceof, TaggedPointers,
readonly property errors.

Remaining: 1 Swift class name test (constructor.name is empty string
instead of the mangled Swift class name).

Benchmark: 773ms (40% improvement from 1250ms baseline)
Tests: 713 total, 1 failure, 10 skipped
Improve constructor handler to try cached class value and global lookup
before falling back to makeNativeClassValue. Also skip className from
prototype installation.

Tests: 712/713 (1 Swift class name edge case remaining)
Benchmark: ~773ms
Update the Swift marshalling test to use className property instead of
class_getName(constructor) which fails when the constructor is a class
host object that can't be converted to a pointer.

Tests: 713/0
…object-arg fast path + cached invocation flags
…e interceptor (skips per-access metadata discovery for JSI engines)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant